SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
15.7 KB · 285 lines tsx
Raw Blame History
1"use client";23import * as React from "react";4import Link from "next/link";5import { useParams } from "next/navigation";6import { ArrowLeft, FlaskConical } from "lucide-react";7import type { GameDefinition, GameSymbol } from "@spinza/game-core";8import { Button } from "@/components/ui";9import { useAdminQuery } from "@/components/admin/use-query";10import type { GameDailyRow, GameDetailResponse, GameVersionRow, SimulationRunRow } from "@/components/admin/types";11import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, TileSkeleton, KV, Pill, LifecyclePill, PassFail, StatGrid, StatTile, Mono, type Column } from "@/components/admin/primitives";12import { Bars, ChartFrame, CHART, Lines } from "@/components/admin/charts";13import { CertificationBlock } from "@/components/admin/simulation-results";14import { dateTime, int, isoDay, multiplier, num, pct, sc, shortDate, signedPct, titleCase } from "@/components/admin/format";15import { VOLATILITY_LABEL, cn } from "@/lib/utils";1617export default function AdminGameDetailPage() {18  const { slug } = useParams<{ slug: string }>();19  const q = useAdminQuery<GameDetailResponse>(slug ? `/api/admin/games/${slug}` : null);20  const d = q.data;21  const [certVersion, setCertVersion] = React.useState<string | null>(null);2223  const anchor = q.updatedAt;24  const daily = React.useMemo(() => (d ? fillDays(d.daily, anchor) : []), [d, anchor]);25  const currentVersion = d?.versions.find((v) => v.version === d.game.version) ?? d?.versions[0] ?? null;26  const shownCert = d?.versions.find((v) => v.version === (certVersion ?? currentVersion?.version))?.certification ?? null;2728  return (29    <>30      <PageHeader31        eyebrow="Game"32        title={d ? d.game.name : "Game detail"}33        description={34          d ? (35            <span className="inline-flex flex-wrap items-center gap-2">36              <Mono>37                {d.game.slug} · v{d.game.version}38              </Mono>39              <LifecyclePill lifecycle={d.game.lifecycle} />40              {currentVersion?.certification ? <PassFail pass={currentVersion.certification.status === "PASS"} label={`cert ${currentVersion.certification.status}`} /> : <Pill tone="warn">not certified</Pill>}41            </span>42          ) : undefined43        }44        actions={45          <>46            <Button variant="ghost" size="sm" href="/admin/games">47              <ArrowLeft className="h-3.5 w-3.5" /> All games48            </Button>49            <RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />50            <Button variant="accent" size="sm" href={`/admin/simulator?slug=${slug}`}>51              <FlaskConical className="h-3.5 w-3.5" /> Run simulation52            </Button>53          </>54        }55      />5657      {q.error && !d ? (58        <ErrorState error={q.error} onRetry={() => void q.refresh()} title={q.error.status === 404 ? "Game not found" : undefined} />59      ) : !d ? (60        <div className="space-y-4">61          <TileSkeleton count={6} cols={6} />62          <Panel>63            <TableSkeleton rows={8} cols={6} />64          </Panel>65        </div>66      ) : (67        <div className={cn("space-y-4", q.stale && "opacity-70")}>68          {/* Stats */}69          <StatGrid cols={6}>70            <StatTile label="Launches" value={int(d.stats?.launches ?? 0)} compact />71            <StatTile label="Spins" value={int(d.stats?.spins ?? 0)} compact />72            <StatTile label="Wagered / won" value={<span>{sc(d.stats?.wagered ?? 0)} <span className="text-fg-4">/</span> {sc(d.stats?.won ?? 0)}</span>} compact />73            <StatTile label="Effective RTP" value={pct(effRtp(d.stats?.wagered, d.stats?.won))} sub={d.definition ? `target ${pct(d.definition.rtp)}` : undefined} tone="accent" compact />74            <StatTile label="Hit rate" value={d.stats?.spins ? pct(num(d.stats.wins) / num(d.stats.spins)) : "—"} sub={`${int(d.stats?.bonuses ?? 0)} bonuses · ${int(d.stats?.freeSpins ?? 0)} FS`} compact />75            <StatTile label="Max win" value={multiplier(d.stats?.maxMultiplier ?? 0)} sub={`${sc(d.stats?.maxWin ?? 0)} · ${int(d.stats?.bigWins ?? 0)} big wins · ${int(d.stats?.favorites ?? 0)} ♥`} compact />76          </StatGrid>7778          <div className="grid gap-4 xl:grid-cols-[1fr_1.4fr]">79            {/* Definition */}80            <Panel title="Definition" description={d.definition ? d.definition.tagline : "Definition missing from the game library"}>81              {d.definition ? <DefinitionSummary def={d.definition} /> : <p className="text-[13px] text-danger">The slug is in the database but no definition is registered in @spinza/games.</p>}82            </Panel>8384            {/* Daily charts */}85            <Panel title="Last 30 days" description="Daily spins, distinct players and effective RTP">86              <div className="grid gap-5 md:grid-cols-3">87                <ChartFrame title="Spins" height={150}>88                  <Bars data={daily} x="day" series={[{ key: "spins", label: "Spins", color: CHART.accent }]} xFormat={(v) => shortDate(String(v))} barSize={8} />89                </ChartFrame>90                <ChartFrame title="Players" height={150}>91                  <Bars data={daily} x="day" series={[{ key: "players", label: "Players", color: CHART.grey }]} xFormat={(v) => shortDate(String(v))} barSize={8} />92                </ChartFrame>93                <ChartFrame title="RTP" height={150}>94                  <Lines data={daily.filter((r) => r.rtp !== null)} x="day" series={[{ key: "rtp", label: "RTP", color: CHART.accent }]} xFormat={(v) => shortDate(String(v))} yFormat={(v) => `${Math.round(v * 100)}%`} reference={d.definition ? { y: d.definition.rtp } : undefined} dots />95                </ChartFrame>96              </div>97              {daily.every((r) => r.spins === 0) ? <p className="mt-3 text-center text-[12px] text-fg-4">No rounds recorded in the last 30 days.</p> : null}98            </Panel>99          </div>100101          {/* Symbols */}102          {d.definition ? (103            <Panel title="Symbols & pays" description={`Pays are multiples of ${d.definition.payModel.type === "ways" ? "a bet unit (total bet ÷ " + d.definition.betDivisor + ")" : "the line bet"}; scatter pays are × total bet`} padded={false}>104              <SymbolsTable def={d.definition} />105            </Panel>106          ) : null}107108          <div className="grid gap-4 xl:grid-cols-[1fr_1.4fr]">109            {/* Versions */}110            <Panel title="Versions" description="Click a version to view its certification" padded={false}>111              <DataTable<GameVersionRow>112                columns={[113                  { key: "version", header: "Version", render: (v) => <span className={cn("font-mono text-[12px]", v.version === d.game.version ? "text-fg" : "text-fg-3")}>v{v.version}{v.version === d.game.version ? <span className="ml-1.5 text-[10px] uppercase text-accent-2">current</span> : null}</span> },114                  { key: "status", header: "Status", render: (v) => <Pill tone={v.status === "published" ? "success" : v.status === "certified" ? "accent" : "muted"}>{v.status}</Pill> },115                  { key: "cert", header: "Certification", render: (v) => (v.certification ? <PassFail pass={v.certification.status === "PASS"} /> : <span className="text-fg-4">none</span>) },116                  { key: "created", header: "Created", render: (v) => <span className="text-fg-3">{dateTime(v.createdAt)}</span> },117                  { key: "hash", header: "Hash", mono: true, render: (v) => <Mono title={v.definitionHash}>{v.definitionHash.slice(0, 12)}</Mono> },118                ]}119                rows={d.versions}120                rowKey={(v) => v.version}121                dense122                onRowClick={(v) => setCertVersion(v.version)}123                rowClassName={(v) => (v.version === (certVersion ?? currentVersion?.version) ? "bg-surface-2" : undefined)}124                empty="No versions recorded."125              />126            </Panel>127128            {/* Simulation runs */}129            <Panel title="Recent simulation runs" description="Latest 10 for this game" padded={false}>130              <DataTable<SimulationRunRow>131                columns={runCols}132                rows={d.simulationRuns}133                rowKey={(r) => r.id}134                dense135                empty="No simulations yet — run one from the simulator."136              />137            </Panel>138          </div>139140          {shownCert ? (141            <CertificationBlock cert={shownCert} title={`Certification · v${certVersion ?? currentVersion?.version}`} />142          ) : (143            <Panel title="Certification" tone="danger">144              <p className="text-[13px] text-fg-2">145                No certification report for v{certVersion ?? d.game.version}. Publishing is blocked until a simulation with <span className="font-semibold">certify</span> enabled passes for the current version.146              </p>147              <Button variant="outline" size="sm" className="mt-3" href={`/admin/simulator?slug=${slug}&certify=1`}>148                <FlaskConical className="h-3.5 w-3.5" /> Certify now149              </Button>150            </Panel>151          )}152        </div>153      )}154    </>155  );156}157158const runCols: Column<SimulationRunRow>[] = [159  { key: "created", header: "Started", render: (r) => <span className="text-fg-3">{dateTime(r.createdAt)}</span> },160  { key: "spins", header: "Spins", align: "right", render: (r) => int(r.spins) },161  { key: "status", header: "Status", render: (r) => <Pill tone={r.status === "done" ? "success" : r.status === "failed" ? "danger" : "info"}>{r.status === "running" ? `${Math.round((num(r.progress) / Math.max(1, num(r.spins))) * 100)}%` : r.status}</Pill> },162  { key: "rtp", header: "Observed RTP", align: "right", render: (r) => (r.result?.simulation ? <span>{pct(r.result.simulation.observedRtp, 3)} <span className="text-fg-4">({signedPct(r.result.simulation.deviation, 2)})</span></span> : "—") },163  { key: "cert", header: "Cert", render: (r) => (r.result?.certification ? <PassFail pass={r.result.certification.status === "PASS"} /> : <span className="text-fg-4">—</span>) },164  { key: "open", header: "", align: "right", render: (r) => <Link href={`/admin/simulator?run=${r.id}`} className="text-[12px] text-fg-3 hover:text-fg">Open →</Link> },165];166167function effRtp(wagered: unknown, won: unknown): number | null {168  const w = num(wagered);169  return w ? num(won) / w : null;170}171172function fillDays(rows: GameDailyRow[], anchor: number) {173  const byDay = new Map(rows.map((r) => [isoDay(r.day), r]));174  const out: { day: string; spins: number; players: number; rtp: number | null }[] = [];175  for (let i = 29; i >= 0; i--) {176    const d = new Date(anchor - i * 86400_000).toISOString().slice(0, 10);177    const r = byDay.get(d);178    out.push({ day: d, spins: r ? num(r.spins) : 0, players: r ? num(r.players) : 0, rtp: r && num(r.wagered) ? num(r.won) / num(r.wagered) : null });179  }180  return out;181}182183const FEATURE_KEYS: (keyof GameDefinition)[] = ["wild", "scatter", "cascades", "spinCollect", "holdRespin", "pickBonuses", "meter", "heat", "mystery", "randomFeatures", "quantum", "dynamicGrid", "jackpot"];184185function DefinitionSummary({ def }: { def: GameDefinition }) {186  const mechanics = FEATURE_KEYS.filter((k) => def[k] !== undefined);187  return (188    <div className="space-y-4">189      <KV190        cols={3}191        items={[192          { label: "Grid", value: `${def.grid.reels} × ${def.grid.rows}` },193          { label: "Pay model", value: def.payModel.type === "ways" ? "Ways" : `${def.payModel.lines.length} lines` },194          { label: "Volatility", value: `${VOLATILITY_LABEL[def.volatility] ?? def.volatility}` },195          { label: "Target RTP", value: pct(def.rtp) },196          { label: "Max multiplier", value: `${int(def.maxMultiplier)}×` },197          { label: "Bets", value: `${sc(def.minBet)} – ${sc(def.maxBet)}` },198          { label: "Pay scale", value: def.payScale.toFixed(4), mono: true },199          { label: "Bet divisor", value: String(def.betDivisor), mono: true },200          { label: "Theme", value: def.theme },201        ]}202      />203      <div>204        <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-fg-4">Features</div>205        <div className="flex flex-wrap gap-1.5">206          {def.featureNames.map((f) => (207            <Pill key={f} tone="accent">208              {f}209            </Pill>210          ))}211          {def.featureNames.length === 0 ? <span className="text-[12px] text-fg-4">—</span> : null}212        </div>213      </div>214      <div>215        <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-fg-4">Mechanics configured</div>216        <div className="flex flex-wrap gap-1.5">217          {mechanics.map((m) => (218            <Pill key={m} tone="neutral">219              {titleCase(String(m))}220            </Pill>221          ))}222          {def.freeSpinsPersistentMultiplier ? <Pill tone="neutral">Persistent FS multiplier</Pill> : null}223        </div>224      </div>225      <div>226        <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-fg-4">Presentation</div>227        <div className="flex flex-wrap items-center gap-2 text-[12px] text-fg-2">228          {(["primary", "secondary", "glow", "bg", "surface"] as const).map((k) => (229            <span key={k} className="inline-flex items-center gap-1">230              <span className="inline-block h-3 w-3 rounded-[3px] border border-line" style={{ background: def.presentation.palette[k] }} />231              {k}232            </span>233          ))}234          <span className="text-fg-4">·</span>235          <span>{def.presentation.particles}</span>236          <span className="text-fg-4">·</span>237          <span>{def.presentation.frame}</span>238          <span className="text-fg-4">·</span>239          <span>{def.presentation.backdrop}</span>240        </div>241      </div>242      {def.tags.length ? <div className="text-[12px] text-fg-4">{def.tags.map((t) => `#${t}`).join("  ")}</div> : null}243    </div>244  );245}246247function SymbolsTable({ def }: { def: GameDefinition }) {248  const counts = Array.from({ length: Math.max(0, def.grid.reels - 2) }, (_, i) => i + 3);249  const fmtWeight = (w: number | number[] | undefined) => (w === undefined ? "—" : Array.isArray(w) ? w.join(" / ") : String(w));250  const cols: Column<GameSymbol>[] = [251    {252      key: "id",253      header: "Symbol",254      render: (s) => (255        <span className="inline-flex items-center gap-2">256          <span className="inline-block h-3.5 w-3.5 rounded-[4px] border border-line" style={{ background: s.style.color, boxShadow: s.style.glow ? `0 0 ${Math.round(s.style.glow * 8)}px ${s.style.accent ?? s.style.color}` : undefined }} />257          <span className="font-medium text-fg">{s.name}</span>258          <Mono className="text-fg-4">{s.id}</Mono>259        </span>260      ),261    },262    { key: "kind", header: "Kind", render: (s) => <Pill tone={s.kind === "regular" ? "muted" : s.kind === "wild" ? "accent" : s.kind === "scatter" ? "info" : "warn"}>{s.kind}</Pill> },263    { key: "tier", header: "Tier", render: (s) => <span className="text-fg-3">{s.tier}</span> },264    { key: "weight", header: "Weight", align: "right", mono: true, render: (s) => fmtWeight(s.weight) },265    { key: "fsweight", header: "FS weight", align: "right", mono: true, render: (s) => (s.freeSpinWeight !== undefined ? fmtWeight(s.freeSpinWeight) : <span className="text-fg-4">—</span>) },266    ...counts.map<Column<GameSymbol>>((c) => ({267      key: `pay${c}`,268      header: `${c}×`,269      align: "right",270      render: (s) => {271        const p = s.pays?.[c];272        const sp = s.scatterPays?.[c];273        if (p === undefined && sp === undefined) return <span className="text-fg-4">·</span>;274        return (275          <span className="tabular">276            {p !== undefined ? p : null}277            {sp !== undefined ? <span className="text-info">{p !== undefined ? " / " : ""}{sp}×bet</span> : null}278          </span>279        );280      },281    })),282  ];283  return <DataTable columns={cols} rows={def.symbols} rowKey={(s) => s.id} dense />;284}285